refactor: use libssz instead of ethereum_ssz#242
Conversation
🤖 Kimi Code ReviewReview SummaryThis PR migrates ethlambda from the legacy ✅ Correctness & Consensus Safety
|
🤖 Codex Code Review
Overall, the SSZ migration itself looks mostly consistent (hash root and encode/decode replacements are generally coherent), but Point 1 is a significant correctness gap in consensus test coverage. Automated review by OpenAI Codex · custom prompt |
🤖 Claude Code ReviewNow I have enough context. Let me write up the review. PR #242 Review: refactor: use libssz instead of ethereum_sszThis is a large mechanical migration from Bug:
|
| rec_aggregation = { git = "https://github.com/leanEthereum/leanMultisig.git", rev = "e4474138487eeb1ed7c2e1013674fe80ac9f3165" } | ||
| # ethereum_ssz is needed for Devnet2XmssAggregateSignature from lean-multisig, | ||
| # which implements the old ethereum_ssz Encode/Decode traits | ||
| ethereum_ssz = { package = "ethereum_ssz", version = "0.10.0" } |
There was a problem hiding this comment.
lean-multisig is still using ethereum_ssz::Encode/Decode traits. So we are depending on both libraries
pablodeymo
left a comment
There was a problem hiding this comment.
Nice work on the migration, clean and well-structured. CI is green and all tests pass. A few observations:
H256(x.hash_tree_root()) wrapping pattern
There are ~25 call sites wrapping hash_tree_root() in H256(...). I checked libssz and confirmed there's no ethereum_types feature flag — HashTreeRoot returns [u8; 32] by design, which makes sense for a generic no_std library.
Since H256 is now a local type, we can add a convenience trait on our side to reduce the boilerplate. Something like:
// In ethlambda_types::primitives
use ssz_merkle::HashTreeRoot;
pub trait HashTreeRootExt: HashTreeRoot {
fn tree_hash_root(&self) -> H256 {
H256(self.hash_tree_root())
}
}
impl<T: HashTreeRoot> HashTreeRootExt for T {}This would turn H256(block.hash_tree_root()) back into block.tree_hash_root() everywhere. Not a blocker for this PR — could be a quick follow-up.
ethereum_ssz residual in crypto crate
The comment in crates/common/crypto/Cargo.toml explains this well — lean-multisig still uses the old ethereum_ssz traits. Just flagging it for tracking so it gets cleaned up when lean-multisig migrates.
ValidatorPubkeyBytes manual impls
The ~40 lines of manual SszEncode/SszDecode/HashTreeRoot for the 52-byte pubkey newtype are correct but boilerplate. Could libssz support arbitrary [u8; N] via const generics? Would eliminate the need for manual impls on uncommon byte array sizes. Low priority.
Bitlist set() no longer returns Result
The old set(i, true).unwrap() pattern is now just set(i, true). Cleaner, but what happens on out-of-bounds? Silent no-op or panic? Worth documenting the contract.
Overall this is a solid migration. The const generics over typenum are a clear readability win, and removing 6 external deps is great for the dependency tree.
Conflicts were in field access patterns (.message → .block rename from main) combined with hash method changes (tree_hash_root → H256(hash_tree_root()) from libssz migration). Resolution: use main's .block.block field path with ssz-lib's H256(hash_tree_root()) method.
🤖 Codex Code Review
No other blocking correctness issues stood out in the SSZ migration itself. I couldn’t run the Rust test suite in this sandbox: Automated review by OpenAI Codex · gpt-5.4 · custom prompt |
Greptile SummaryThis PR migrates the entire SSZ dependency stack from Key changes:
One issue to address: Confidence Score: 4/5Safe to merge after fixing the H256 JSON serialization prefix issue; everything else is a mechanical API migration with well-bounded scope. The migration is thorough and mechanically correct across 35 files. The pinned hash-root test in genesis.rs confirms encoding compatibility. The only P1 finding is H256::Serialize producing un-prefixed hex instead of the documented '0x...' form. All other findings are P2. crates/common/types/src/primitives.rs — H256 Serialize implementation and from_slice behavior.
|
| Filename | Overview |
|---|---|
| crates/common/types/src/primitives.rs | Defines the new H256 newtype and re-exports libssz types. H256 serialize is missing the 0x prefix (P1) and from_slice silently truncates/pads (P2). |
| crates/common/types/src/state.rs | Replaces typenum-based limits with const generics, adds ValidatorPubkeyBytes newtype with manual SSZ/HashTreeRoot impls. State no longer derives Serialize (intentional). |
| crates/blockchain/state_transition/src/justified_slots_ops.rs | Replaces BitList::union with manual bit-by-bit copy in extend_to_slot; semantically equivalent. |
| crates/blockchain/state_transition/src/lib.rs | Updates tree hash calls, adapts attestation vote iteration to libssz API, functionally equivalent. |
| crates/blockchain/src/store.rs | Mechanical SSZ API migration. num_participants now uses entries.len() which is equivalent to the old num_set_bits(). |
| crates/storage/src/store.rs | Straightforward as_ssz_bytes→to_ssz and Encode/Decode renames throughout. No logic changes. |
| crates/common/crypto/src/lib.rs | Correctly keeps ethereum_ssz as a direct dep for lean-multisig compatibility. |
| Cargo.toml | Replaces ethereum_ssz/ssz_types/tree_hash with libssz family. Also drops ethereum-types since H256 is now a custom type. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
subgraph Before["Before"]
A1["ethereum_ssz + ethereum_ssz_derive\nEncode / Decode"]
B1["ssz_types\nVariableList / FixedVector / BitList"]
C1["tree_hash + tree_hash_derive\nTreeHash / tree_hash_root()"]
D1["ethereum-types\nH256 = Hash256"]
end
subgraph After["After"]
A2["libssz + libssz-derive\nSszEncode / SszDecode"]
B2["libssz-types\nSszList / SszVector / SszBitlist"]
C2["libssz-merkle\nHashTreeRoot / hash_tree_root()"]
D2["Local newtype\nstruct H256([u8;32])"]
end
A1 -->|renamed| A2
B1 -->|const generic| B2
C1 -->|renamed| C2
D1 -->|hand-rolled| D2
A1 -.->|retained in ethlambda-crypto\nfor lean-multisig bridge| A1
Comments Outside Diff (1)
-
crates/blockchain/src/key_manager.rs, line 470-471 (link)Debug format in user-facing error string
format!("{e:?}")uses theDebugrepresentation (includes Rust type names, enum variant names, etc.), producing noisy output likeSignatureConversionError(SomeInternalError { ... }). A Display format (format!("{e}")) is more appropriate for strings that end up in error messages shown to callers.The same pattern was applied in
signature.rs— aligning them would also make the error style consistent.Prompt To Fix With AI
This is a comment left during a code review. Path: crates/blockchain/src/key_manager.rs Line: 470-471 Comment: **Debug format in user-facing error string** `format!("{e:?}")` uses the `Debug` representation (includes Rust type names, enum variant names, etc.), producing noisy output like `SignatureConversionError(SomeInternalError { ... })`. A Display format (`format!("{e}")`) is more appropriate for strings that end up in error messages shown to callers. The same pattern was applied in `signature.rs` — aligning them would also make the error style consistent. How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: crates/common/types/src/primitives.rs
Line: 51-54
Comment:
**`H256` serializes without `0x` prefix, contradicting the doc comment**
The `Serialize` impl uses `format!("{:x}", self)` (via `LowerHex`) which produces raw hex digits with **no** `0x` prefix, e.g. `"abc123..."`. But the struct-level doc says _"Serialized as a `"0x..."` hex string"_ and `Display` adds the prefix. The deserialized path handles both forms, but any JSON consumer (checkpoint-sync clients, external tooling) expecting the `"0x..."` convention will receive un-prefixed strings instead.
```suggestion
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&format!("{}", self))
}
```
Switching to `Display` (`"{}"`) is the simplest fix and keeps the format consistent with the doc comment and with Ethereum conventions.
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: crates/common/types/src/primitives.rs
Line: 86-91
Comment:
**`from_slice` silently truncates or pads instead of erroring**
If `bytes.len() > 32`, the extra bytes are silently dropped; if `bytes.len() < 32`, the result is zero-padded. Both cases are lossy and unexpected — callers probably intend to pass exactly 32 bytes. A panic (or a `Result`) on a wrong-length input would make misuse immediately visible rather than propagating a silently wrong hash.
```suggestion
pub fn from_slice(bytes: &[u8]) -> Self {
let arr: [u8; 32] = bytes.try_into().expect("H256::from_slice requires exactly 32 bytes");
Self(arr)
}
```
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: crates/blockchain/src/key_manager.rs
Line: 470-471
Comment:
**Debug format in user-facing error string**
`format!("{e:?}")` uses the `Debug` representation (includes Rust type names, enum variant names, etc.), producing noisy output like `SignatureConversionError(SomeInternalError { ... })`. A Display format (`format!("{e}")`) is more appropriate for strings that end up in error messages shown to callers.
```suggestion
.map_err(|e| KeyManagerError::SignatureConversionError(format!("{e}")))?;
```
The same pattern was applied in `signature.rs` — aligning them would also make the error style consistent.
How can I resolve this? If you propose a fix, please make it concise.Reviews (1): Last reviewed commit: "chore: update to version 0.2 of libssz" | Re-trigger Greptile
Bug: H256 JSON serialization drops
|
pablodeymo
left a comment
There was a problem hiding this comment.
please review the reported bug
pablodeymo
left a comment
There was a problem hiding this comment.
Review: second pass on final state
Previous findings (H256 serialization, from_slice truncation) are fixed — nice work on the tests too.
Two remaining observations:
1. as_bytes() drops SSZ sentinel in dedup key (crates/storage/src/store.rs)
let key_bytes = payload.proof.participants.as_bytes().to_vec();The old as_ssz_bytes() included the sentinel bit that encodes the bitlist length. as_bytes() does not, so two bitlists of different lengths with identical underlying bytes would produce the same key. Unlikely to trigger in practice (same data_root group → same length), but payload.proof.participants.to_ssz() preserves the original semantics and is a one-line fix.
2. num_participants metric uses entries.len() instead of bitfield count (crates/blockchain/src/store.rs)
The old num_set_bits() counted participants from the attestation bitfield. The new entries.len() counts store entries — equivalent today but fragile if the mapping ever diverges. aggregated.proof.participants.count_ones() is the direct libssz equivalent.
Neither is blocking. Everything else — wire format, hash roots, type limits, serde, bitfield ops — looks correct. Solid migration.
This PR moves to using
libssz, our own SSZ library, which is faster and has no_std support.We still keep
ethereum_sszas a transitive dependency from lean-multisig, but this might change in the future.